Skip to content

feat(local-model): pick the coding agent per session, with Pi and DeepSeek Harness, and install a missing one - #806

Merged
Ark0N merged 12 commits into
masterfrom
feat/local-model-harness-choice
Nov 12, 2026
Merged

Ark0N merged 12 commits into
masterfrom
feat/local-model-harness-choice

Conversation

@Ark0N

@Ark0N Ark0N commented Nov 11, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #804 (the model library). The base of this PR is that branch, so the diff shows only what is new here. Merge #804 first; GitHub then retargets this one to master.

What this changes

"Run a local model" is the window that installs a coding model on the Mac the Codeman server runs on. A model is driven by a coding agent CLI (a "harness"). Until now an installed model knew exactly one agent, the one it was installed with, and only OpenCode and Claude Code were offered.

This PR makes the agent a per-session choice and adds two agents:

  • Pick the agent when you start a session. The installed screen has a new block, "Start a session with": one chip per agent the model can be run with. The device remembers the agent a session was last started with.
  • Pi and DeepSeek Harness are offered, next to OpenCode and Claude Code.
  • Install a missing agent from the same screen. A chip that says "Not installed" shows the literal install command, and the main button becomes "Install Pi" (or whichever agent). The model stays ready while the installer runs.
  • Pi learns the model's context window (a fix to the custom model feature as a whole, in its own commit).

The four agents, and what each rests on

Agent First request (its fixed prompt) Installed from the window What was seen, and where
OpenCode about 8,100 tokens, 11 tools (v1.19.5) yes A tool round trip on a Mac with a real model (the catalogue's existing tested entry), and on a scripted endpoint for v1.19.5
Claude Code about 36,000 tokens, so the first answer takes minutes yes Shipped in 1.50.0. Not measured again here
Pi 2,096 tokens, 4 tools (v1.5.2) yes A complete tool round trip against a real model: tool call, tool result, final answer, the file on disk
DeepSeek Harness about 7,650 tokens, 25 tools (0.1.1-rc.2); 6,983 tokens, 27 tools (0.2.2-rc.1, today's) no A tool round trip on a scripted endpoint only, on both versions. Against a real model the launch and the model name reached the runtime, and no turn finished

How to read the last column. The runs were made on 2026-11-10 on Linux, through Codeman's own custom model launch:

  • "a real model" is Ollama 0.13.0 with a 1.7B model on a CPU;
  • "a scripted endpoint" replays that runtime's stream of a tool call. It proves the agent's side of the protocol, not what a model does with the prompt.

None of it ran on a Mac or on the pinned runtime (Ollama 0.40.15). Every agent sent the name:tag model name unchanged.

Decisions a maintainer should look at

  1. DeepSeek Harness is offered on thin evidence. Its row is not gated although no turn finished against a real model on this feature's runtime (the test box ran out of memory first). What speaks for it: the scripted round trip, and that its custom endpoint recipe was already verified with a real model on a llama.cpp server (docs/custom-model-endpoints.md). It is ranked last and never installed from here, so only a Mac that already has it set up sees it as startable. To gate it again: gated: true on its row in src/config/local-model-catalog.ts, one line.
    Two things today's DeepSeek Harness (0.2.2-rc.1 with dsh-TUI 0.19.0) does that are worth knowing, both independent of this PR:
    • A setup guide opens in every session until a person has finished it once. In a tab that is a few key presses. For a script it is a trap, because the guide's cursor is the same glyph as the composer; the skill PR on top of this one (feat(skill): spawn a local model worker with the coding agent of your choice (local:pi, local:deepseek) #807) refuses such a worker instead of typing into it.
    • Its transcript file was renamed (session.v4.jsonl.zstd), so Codeman's response viewer and last-response are empty for a dsh session on that version. That is a bug of its own, for every dsh session, and is not fixed here.
  2. DeepSeek Harness is never installed from this window. dsh is a launcher: its install command installs the launcher, and a session also needs a terminal profile, which is third-party code behind its own consent in the Run menu. So it shows "Needs setup" until the Run menu can start it. The rule reads the registry entry's discovery.launcherProfile, not the CLI id. Folding the profile install into this window is possible and is a decision of its own.
  3. Pi has two rough edges that are not fixed here, both because a custom model session gives Pi a home directory of its own:
    • it downloads its two search tools again at every start (needs the network; PI_OFFLINE=1 skips it);
    • it asks "Trust project folder?" in every such session when a folder above the working directory holds agent skills, because the answer is stored in the home it does not keep.
      A person in a tab can answer the question. A script cannot, which is why the follow-up for the codeman skill starts Pi with --no-approve.
  4. "Start a session" now needs an agent that can be started. With none on the Mac the button is "Install OpenCode" instead of a session start that fails in the launcher.

How it is built

  • The launch check never asked about the agent, only about the endpoint and the model, so nothing in the safety rules changes. One model is still loaded and connected at a time.
  • GET /api/local-model gains install.harnesses[]: { cli, cliLabel, default, status, installCommand?, slowFirstTurn?, readyMark?, error?, actions }. status is ready, installable, installing or setup-needed. readyMark is a literal the agent's composer draws once it takes input, for a script that waits before its first prompt; only a measured mark is listed (OpenCode ┃, DeepSeek Harness ❯, and Claude Code's shift+tab from its registry entry; Pi has none on purpose, its composer is painted before it accepts Enter).
  • install.cli stays the default agent for a client that reads only that field (the codeman skill shipped in 1.50.0 does). It now names an agent that can be started whenever one can, never one that was uninstalled.
  • POST /api/local-model/agent { cli } installs one more agent beside an installed model. It is deliberately not a job of the install state machine: a job would put a working model into failed on an npm error and refuse every session until someone cleared it. The state stays installed, the installer runs in the background through the same install slot and the same compiled stock command as the install's own agent step, one at a time, audited (cli_install, via: "local-model"), and its outcome is kept in memory only. The request carries a CLI id, never a command.
  • A switch to another model no longer refuses because the agent that model was checked with is gone, when another agent that can run it is on the Mac.
  • The catalogue: pi is no longer gated, deepseek is a new row, a row may carry readyMark, and every row asks for 65,536 tokens (nothing below was run, and no model row offers less, so no Mac's recommended model changes). The catalogue header has a table of what each row rests on.

The Pi fix (commit a5a1b6f9, independent of the rest)

A Pi session on any custom endpoint got a model list entry with only the model id. Pi then takes the model for a 128,000-token one and asks for 16,384 reply tokens, so on a 65,536 window it never compacts before the server's real window is full. The entry now carries contextWindow and maxTokens when the endpoint's context length is known, the same two figures the DeepSeek Harness recipe already had. Measured on pi 1.5.2: pi --list-models shows the window and the request carries max_tokens: 8192.

API (experimental routes, outside the stable /api/v1 contract)

Route Body Refusals
POST /api/local-model/agent { cli } 400 not a row of install.harnesses, or an agent one command does not make startable; 409 job-running (a job or another agent install runs), wrong-state (nothing is installed)

Admin only in multi-user mode, checked before the body is parsed, like every other route of the feature.

Testing

  • CI on this PR is the full gate.
  • 41 targeted test files, 1,931 tests on the final tree: every test/local-model-* and test/routes/local-model-* file, the custom model tests, the i18n harvest, both CLI id-branching guards and the HTTP contract test.
  • New tests: the agent rows as a pure function (order, default, every status), the agent install in the service (started, failed, retried, one at a time, already there, refused, ended by a server stop, the state file untouched), the route (admin gate, schema, 400 and 409 with their sentences), the launcher rule and the readiness mark in the harness facts, the Pi model entry, and 15 window tests (select, start with the selected agent, install, failure, keyboard, a stopped model, Chinese, text shown as text, and a chip that was only looked at never hides "Start a session" when the window is opened again).
  • Typecheck, lint, format check, frontend syntax and public assets checks: clean.
  • npm run test:browser -- test/local-model-ui.browser.test.ts (real Chromium, not part of the gate): 11 passed, one of them new (the chips wrap inside the window at 1440, 390 and 320 px, the install command included, with one tab stop). The screen was also looked at on a dark and a light skin with a scratch script.
  • The combined tree of this branch and feat(local-model): list every model that fits, and add Muse Glimmer 30B and Ornith 1.0 9B #803 (more models) was built and its catalogue, recommendation and route tests pass (565 tests), so the two merge without a semantic conflict.

Run on a real Mac (2026-11-12)

A MacBook Pro (M5 Pro, 48 GB, macOS 27.2) with the pinned runtime, Ollama 0.40.15. Its Codeman ran the tree of this stack (#803 to #808) merged onto 1.50.1. The window was driven by real clicks, from a Chromium on another machine through an SSH forward to the Mac's own server, so every state on screen was the Mac's.

  • The chips on a real install: OpenCode and Claude Code startable, Pi "Not installed" with its command under it, DeepSeek "Needs setup".
  • "Install Pi", by a real click: npm install -g --ignore-scripts @earendil-works/pi-coding-agent ran for about 5 seconds. The chip went from "Installing" to startable, the install state stayed installed the whole time, and the audit log holds the cli_install entry with via: "local-model".
  • A Pi session started from the window, on Qwen3.6 35B-A3B: Pi 1.5.2 showed a 131k window in its footer (the context window fix of this PR, seen working), wrote a module and its test, and ran it: 6 tests pass, about 40 seconds for the turn.
  • OpenCode sessions on three models (Qwen3.5 9B, Ornith 1.0 9B, Qwen3.6 35B-A3B) each finished a turn with a tool call.
  • Pi's rough edges on the Mac: it downloaded fd and ripgrep at the start of every session there too. It did not ask about folder trust, because no folder above the project holds agent skills on that Mac.
  • The runtime ignores max_completion_tokens: asked for 64 tokens, Ollama 0.40.15 returned 1,235. With max_tokens: 64 it returned 64. DeepSeek Harness caps its requests with the former, so its small side requests run uncapped on this runtime. One more reason its row is ranked last.

Not run on the Mac: DeepSeek Harness (it is not set up there), and Claude Code ON the local model (Claude Code ran there as the cloud lead of the skill test in #807).

Not proven, stated plainly

  • One Mac only (M5 Pro, 48 GB).
  • Claude Code was not run on the local model in this round (its row shipped in 1.50.0).
  • DeepSeek Harness has no finished turn against a real model on either version; both of its tool round trips are scripted.
  • Only Pi's install ran for real. OpenCode's and Claude Code's go through the same code with their own stock commands, and were exercised through fakes.
  • The window was driven in Chromium, against the Mac's server. It was not looked at inside the macOS app's WKWebView.

Files

Server: src/local-model/{install-job,harness-facts}.ts, src/types/{local-model,local-model-install}.ts, src/web/routes/local-model-routes.ts, src/web/schemas.ts, src/config/local-model-catalog.ts, src/custom-model-injection.ts. Window: src/web/public/{local-model-ui.js,styles.css,i18n.js}. Tests: test/local-model-{install-job,install-service,harness-facts,catalog,recommend,ui}.test.ts, test/routes/local-model-routes.test.ts, test/custom-model-injection.test.ts. Docs: CLAUDE.md, src/web/CLAUDE.md, docs/{local-models,api-reference,architecture-invariants,custom-model-endpoints}.md.

The local model window could hold one model. A second install was refused
(409 already-installed), and a model a Remove kept could never be freed
from the window again. The store can now hold several models, and exactly
one of them is the model in use: the one the endpoint record lists, the
runtime is started for and a session may be launched on.

Server
- LocalModelStateFile.library lists every model that passed the check
  (id, label, context, digest, size, coding agent). The service writes
  the entry when verify passes; the reducer carries it and remove drops
  it. An installed file of an earlier build counts its one model.
- POST /api/local-model/install is allowed from installed: it adds a
  model, and the earlier one stays. A replaced job no longer deletes a
  model of the library, only an unchecked or partial download.
- POST /api/local-model/use switches to a checked model: a job of the
  two steps verify and connect. The stored digest is compared before
  anything is loaded (model-changed otherwise). Nothing is downloaded.
- DELETE /api/local-model/models deletes one model by its name, through
  the runtime's own delete, never by a path, never the model in use.
- Adding and switching answer 409 sessions-open while a live session
  uses the endpoint. Until such a job ends installed, a session start is
  refused with the new code job-unfinished.
- The view carries library (the state's entries joined with what the
  running runtime lists: ready, changed, missing, unverified) and store
  (the size of the blob folder). Both are cached and refreshed in the
  background, never read on the one-second poll.
- With nothing installed, remove is offered while the store still holds
  models an earlier Remove kept.

Window
- The installed screen lists the models on this Mac with the context
  each was checked with, its state and its size on disk, with Use and
  Delete per row (Delete asks once, in the row) and Add a model.
- In the chooser a model that is on this Mac already says so and is
  switched to without a download or a confirm screen.
- A paused or failed job that has a checked model beside it shows the
  way back to that model.

Every route is admin only in multi-user mode, like the existing ones.

Not run on a Mac: the tests use fakes and the fake runtime, and the
listing, store size and delete were checked against a real Ollama 0.13.0
on Linux, not the pinned 0.40.15 build for macOS.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The guide, the API reference, the invariants and the two CLAUDE files
describe the library with one model in use: the state file's list, the
three verbs and their refusals, the two cached listings of the view, and
the window's list with its row actions.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
… the Stop

A job started from a model the person had stopped runs the runtime, and
the model it ends on is in use and running. The stored "stopped" flag
stayed set, so that model would not have started with the server again.
Both verbs now set it back once their job started, the way Start does.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A pi session on a custom endpoint got a model list entry with only the
model id. pi then takes the model for a 128,000-token one (its footer
reads "/128k") and asks for 16,384 reply tokens, so on a 65,536 or
32,768 window it never compacts before the server's real window is full.

The entry now carries `contextWindow` (the full window) and `maxTokens`
(a quarter of it, 8,192 at most) whenever the endpoint's context length
is known: the same two figures the DeepSeek Harness patch already
carries, now from one helper. An unknown length writes neither, as
before.

Measured on pi 1.5.2: with both fields `pi --list-models` shows the
window, and the request carries `max_tokens: 8192`.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…d DeepSeek Harness

An installed model knew one coding agent: the one it was installed with.
The launch check never asked about the agent, only about the endpoint
and the model, so nothing stopped a second one. What was missing is the
list, and a way to get an agent that is not on the Mac yet.

The install view gains `harnesses`: every catalogue harness the model in
use can be run with, each with how it stands (`ready`, `installable`,
`installing`, `setup-needed`), the literal install command, a readiness
mark for scripts, and the actions the server accepts for it. `cli` stays
the default agent, and names one that can be started whenever one can,
so a client that reads only `cli` never starts an agent that is gone.

`POST /api/local-model/agent { cli }` installs one more agent beside an
installed model. It is not a job and not an event of the reducer: the
state stays `installed` whatever the installer does, so a failed npm
install never blocks a session on a model that works. Same install slot
and same compiled stock command as the `agent` step, one at a time, the
outcome in memory only.

The catalogue:
- `pi` is no longer gated. Its two open questions were answered by a run
  against a real model (the `name:tag` id reaches the runtime unchanged;
  its first request is 2,096 tokens), and Codeman now passes it the
  window. Its minimum is 65536 like the others: nothing below was run,
  and no model row offers less, so no Mac's pick changes.
- `deepseek` is new. It is a launcher: its install command leaves it
  with nothing to launch, so it is never reported installable (read off
  `discovery.launcherProfile`, not the id) and a Mac is offered it only
  where it was already set up. Its tool round trip was seen on a
  scripted endpoint only; the header says how to gate it again.
- a row may carry `readyMark`; `opencode` has the measured one.

A switch no longer refuses for the agent a model was checked with when
another agent that can run it is on the Mac.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…ne that is missing

The installed screen gets one more block, "Start a session with": a chip
per coding agent the model in use can be run with, as a radio group, the
server's default selected. A chip that is not on the Mac says so.

The main button follows the selected chip:
- an agent that can be started: "Start a session", with that agent;
- an agent that is not on the Mac: its literal install command above the
  button, and "Install <agent>". The click is the consent; the server
  runs its own stock command, and the window shows "Installing" on the
  chip while the model stays ready and another agent can be started;
- an agent that needs a setup of its own (DeepSeek Harness without a
  terminal profile): one line that says where, nothing to press.

A failed install shows the server's sentence under the chip with the
installer's last words folded away; the model's own headline is
untouched. On a stopped model Start stays the one main button. The
choice is remembered on the device. A server that lists no agents gets
the screen it had.

Rendered in Chromium at 1440, 390 and 320 px on a dark and a light
skin: the chips wrap, nothing leaves the window.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…ed with, and the agent install

- docs/local-models.md: the four agents with their first request, whether
  the window installs them and what was seen where (a real model, a
  scripted endpoint, Linux, not the pinned runtime); what differs for Pi
  and for DeepSeek Harness; installing one more agent beside a model.
- docs/api-reference.md: `POST /api/local-model/agent`, `install.harnesses`,
  the `install-agent` action, and `install.cli` as the default agent.
- docs/architecture-invariants.md, CLAUDE.md, src/web/CLAUDE.md: the
  rules: the agent is chosen per session, the agent install is not a
  job, a launcher is never installable, a readiness mark is a measured
  one, and the window's one builder for the agent chips.
- docs/custom-model-endpoints.md: Pi's context window.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A second scripted run, on dsh 0.2.2-rc.1 with a freshly installed
dsh-TUI 0.19.0 under a scratch home: the model name reaches the endpoint
unchanged, the first request is 6,983 tokens with 27 tools, and a tool
round trip completes.

Two things that version does differently are written down where a reader
of the harness table looks: a setup guide opens in every session until a
person has finished it once (and its cursor is the readiness mark), and
its transcript file was renamed, so `last-response` is empty for it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
… session"

Selecting a chip stored it for the device at once. Someone who clicked
"DeepSeek" (Needs setup) or "Pi" (Not installed) just to see what it
says then got a window that reopened on that chip: "Install Pi", or no
main button at all, with "Start a session" out of sight.

The device now remembers the agent a session was STARTED with, an
opening of the window selects it only while it can be started, and a
selection made in one opening does not carry into the next. Otherwise
the server's default agent is selected, as before.

Also: a real-browser test for the chips (they wrap inside the window at
1440, 390 and 320 px, the install command included, one tab stop), and
one sentence in the API reference: a readiness mark says the composer is
there, not that nothing else is (DeepSeek Harness's setup guide draws
the same glyph).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…his model" refusal

Seen on a MacBook (M5 Pro, 2026-11-12): with a worker of the codeman skill open on the
local model, Stop, Remove and a switch of models answered

    Sessions are still using this model: . Close them first.

A session an agent starts has no name of its own (a quick-start without sessionName), and
the refusal listed session.name. The tab of such a session shows the last folder of its
working directory, else the first eight characters of its id, so the sentence now uses the
same label (sessionLabel() beside sessionNamesUsingEndpoint()).

The sentence has shipped since 1.50.0 for Stop and Remove. This branch adds it to Use and
to the install of another model, which is where a person with local workers open meets it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…-button screen

Seen on a MacBook (M5 Pro, 2026-11-12): pressing Use on a model in "Models on this Mac"
ran the switch and then showed the screen that ends an install, "X is ready on this Mac"
with Start a session alone. The list was gone, so deleting the model just switched away
from meant closing the window and opening it again.

That screen is right after a download: the person installed a model and wants a session.
A switch is pressed IN the list by someone who is tidying it. A switch is a job without a
`model` step, so the window now tells the two apart from the job it just watched and ends a
switch on the whole installed screen: the list with "In use" on the other row, Add a model,
Stop and Remove.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@Ark0N
Ark0N changed the base branch from feat/local-model-library to master November 12, 2026 02:03
@Ark0N
Ark0N merged commit d4928ba into master Nov 12, 2026
4 checks passed
Ark0N pushed a commit that referenced this pull request Nov 12, 2026
…arness, and install a missing one

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Nov 12, 2026
… and agent verbs (#804, #806)

POST /api/local-model/use, DELETE /api/local-model/models and
POST /api/local-model/agent are experimental like the routes beside them,
and so are the install object's library, store and harnesses lists.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants